| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470 |
- import { useEffect, useMemo, useState } from 'react';
- import {
- Image,
- KeyboardAvoidingView,
- Platform,
- Pressable,
- ScrollView,
- StyleSheet,
- Switch,
- TextInput,
- View,
- } from 'react-native';
- import * as ImagePicker from 'expo-image-picker';
- import { ResizeMode, Video } from 'expo-av';
- import { useLocalSearchParams } from 'expo-router';
- import { ThemedButton } from '@/components/themed-button';
- import { ThemedText } from '@/components/themed-text';
- import { ThemedView } from '@/components/themed-view';
- import { ZoomImageModal } from '@/components/zoom-image-modal';
- import { Colors } from '@/constants/theme';
- import { useColorScheme } from '@/hooks/use-color-scheme';
- import { useTranslation } from '@/localization/i18n';
- import { dbPromise, initCoreTables } from '@/services/db';
- type TaskRow = {
- id: number;
- name: string;
- description: string | null;
- };
- type EntryRow = {
- id: number;
- status: string | null;
- notes: string | null;
- meta_json: string | null;
- completed_at: string | null;
- };
- type MediaRow = {
- uri: string | null;
- };
- export default function TaskDetailScreen() {
- const { t } = useTranslation();
- const { id } = useLocalSearchParams<{ id?: string | string[] }>();
- const taskId = Number(Array.isArray(id) ? id[0] : id);
- const theme = useColorScheme() ?? 'light';
- const palette = Colors[theme];
- const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []);
- const [task, setTask] = useState<TaskRow | null>(null);
- const [entryId, setEntryId] = useState<number | null>(null);
- const [status, setStatus] = useState('');
- const [isDone, setIsDone] = useState(false);
- const [notes, setNotes] = useState('');
- const [mediaUris, setMediaUris] = useState<string[]>([]);
- const [activeUri, setActiveUri] = useState<string | null>(null);
- const [zoomUri, setZoomUri] = useState<string | null>(null);
- const [saving, setSaving] = useState(false);
- const [showSaved, setShowSaved] = useState(false);
- useEffect(() => {
- let isActive = true;
- async function loadTask() {
- try {
- await initCoreTables();
- const db = await dbPromise;
- const taskRows = await db.getAllAsync<TaskRow>(
- 'SELECT id, name, description FROM daily_tasks WHERE id = ? LIMIT 1;',
- taskId
- );
- const entryRows = await db.getAllAsync<EntryRow>(
- `SELECT id, status, notes, meta_json, completed_at
- FROM daily_task_entries
- WHERE task_id = ? AND substr(completed_at, 1, 10) = ?
- LIMIT 1;`,
- taskId,
- todayKey
- );
- if (!isActive) return;
- setTask(taskRows[0] ?? null);
- const entry = entryRows[0];
- if (entry) {
- setEntryId(entry.id);
- setNotes(entry.notes ?? '');
- const entryStatus = entry.status ?? '';
- setStatus(entryStatus);
- setIsDone(entryStatus === 'done');
- const mediaRows = await db.getAllAsync<MediaRow>(
- 'SELECT uri FROM task_entry_media WHERE entry_id = ? ORDER BY created_at ASC;',
- entry.id
- );
- const media = uniqueMediaUris(mediaRows.map((row) => row.uri).filter(Boolean) as string[]);
- const fallback = parseTaskMeta(entry.meta_json)?.photoUri;
- const merged = uniqueMediaUris([
- ...media,
- ...(normalizeMediaUri(fallback) ? [normalizeMediaUri(fallback) as string] : []),
- ]);
- setMediaUris(merged);
- setActiveUri(merged[0] ?? null);
- }
- } catch (error) {
- if (isActive) setStatus(`Error: ${String(error)}`);
- }
- }
- loadTask();
- return () => {
- isActive = false;
- };
- }, [taskId, todayKey]);
- const inputStyle = [
- styles.input,
- {
- borderColor: palette.border,
- backgroundColor: palette.input,
- color: palette.text,
- },
- ];
- async function handleSave(nextStatus?: string) {
- if (!task) return;
- try {
- setSaving(true);
- const db = await dbPromise;
- const now = new Date().toISOString();
- const statusValue = nextStatus ?? (isDone ? 'done' : 'open');
- let currentEntryId = entryId;
- if (!currentEntryId) {
- const result = await db.runAsync(
- 'INSERT INTO daily_task_entries (task_id, field_id, notes, status, completed_at, created_at, meta_json) VALUES (?, NULL, ?, ?, ?, ?, ?);',
- task.id,
- notes.trim() || null,
- statusValue,
- now,
- now,
- serializeTaskMeta({ photoUri: mediaUris[0] })
- );
- currentEntryId = Number(result.lastInsertRowId);
- setEntryId(currentEntryId);
- } else {
- await db.runAsync(
- 'UPDATE daily_task_entries SET notes = ?, status = ?, completed_at = ?, meta_json = ? WHERE id = ?;',
- notes.trim() || null,
- statusValue,
- now,
- serializeTaskMeta({ photoUri: mediaUris[0] }),
- currentEntryId
- );
- }
- if (currentEntryId) {
- await db.runAsync('DELETE FROM task_entry_media WHERE entry_id = ?;', currentEntryId);
- for (const uri of uniqueMediaUris(mediaUris)) {
- await db.runAsync(
- 'INSERT INTO task_entry_media (entry_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
- currentEntryId,
- uri,
- isVideoUri(uri) ? 'video' : 'image',
- now
- );
- }
- }
- setStatus(statusValue === 'done' ? t('tasks.done') : t('tasks.open'));
- setShowSaved(true);
- setTimeout(() => {
- setShowSaved(false);
- setStatus('');
- }, 1800);
- } catch (error) {
- setStatus(`Error: ${String(error)}`);
- } finally {
- setSaving(false);
- }
- }
- return (
- <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
- <KeyboardAvoidingView
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
- style={styles.keyboardAvoid}>
- <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
- <ThemedText type="title">{task?.name ?? t('tasks.title')}</ThemedText>
- {task?.description ? <ThemedText>{task.description}</ThemedText> : null}
- <View style={styles.statusRow}>
- <ThemedText>{t('tasks.done')}</ThemedText>
- <Switch
- value={isDone}
- onValueChange={(value) => setIsDone(value)}
- trackColor={{ false: palette.border, true: palette.tint }}
- thumbColor={palette.card}
- />
- </View>
- <ThemedText>{t('tasks.notePlaceholder')}</ThemedText>
- <TextInput
- value={notes}
- onChangeText={setNotes}
- placeholder={t('tasks.notePlaceholder')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- multiline
- />
- <ThemedText>{t('tasks.addMedia')}</ThemedText>
- {normalizeMediaUri(activeUri) ? (
- isVideoUri(normalizeMediaUri(activeUri) as string) ? (
- <Video
- source={{ uri: normalizeMediaUri(activeUri) as string }}
- style={styles.mediaPreview}
- useNativeControls
- resizeMode={ResizeMode.CONTAIN}
- />
- ) : (
- <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
- <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
- </Pressable>
- )
- ) : (
- <ThemedText style={styles.photoPlaceholder}>{t('tasks.photo')}</ThemedText>
- )}
- {mediaUris.length > 0 ? (
- <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
- {mediaUris.map((uri) => (
- <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
- {isVideoUri(uri) ? (
- <View style={styles.videoThumb}>
- <ThemedText style={styles.videoThumbText}>▶</ThemedText>
- </View>
- ) : (
- <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
- )}
- <Pressable
- style={styles.mediaRemove}
- onPress={(event) => {
- event.stopPropagation();
- setMediaUris((prev) => {
- const next = prev.filter((item) => item !== uri);
- setActiveUri((current) => (current === uri ? next[0] ?? null : current));
- return next;
- });
- }}>
- <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
- </Pressable>
- </Pressable>
- ))}
- </ScrollView>
- ) : null}
- <View style={styles.photoRow}>
- <ThemedButton
- title={t('tasks.pickFromGallery')}
- onPress={() =>
- handlePickMedia((uris) => {
- if (uris.length === 0) return;
- setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
- setActiveUri((prev) => prev ?? uris[0]);
- })
- }
- variant="secondary"
- />
- <ThemedButton
- title={t('tasks.takeMedia')}
- onPress={() =>
- handleTakeMedia((uri) => {
- if (!uri) return;
- setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
- setActiveUri((prev) => prev ?? uri);
- })
- }
- variant="secondary"
- />
- </View>
- <View style={styles.actions}>
- <View style={styles.updateGroup}>
- {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('tasks.saved')}</ThemedText> : null}
- <ThemedButton
- title={saving ? t('tasks.saving') : t('tasks.save')}
- onPress={() => handleSave()}
- disabled={saving}
- />
- </View>
- </View>
- </ScrollView>
- </KeyboardAvoidingView>
- <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
- </ThemedView>
- );
- }
- async function handlePickMedia(onAdd: (uris: string[]) => void) {
- const result = await ImagePicker.launchImageLibraryAsync({
- mediaTypes: getMediaTypes(),
- quality: 1,
- allowsMultipleSelection: true,
- selectionLimit: 0,
- });
- if (result.canceled) return;
- const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
- if (uris.length === 0) return;
- onAdd(uris);
- }
- async function handleTakeMedia(onAdd: (uri: string | null) => void) {
- const permission = await ImagePicker.requestCameraPermissionsAsync();
- if (!permission.granted) return;
- const result = await ImagePicker.launchCameraAsync({
- mediaTypes: getMediaTypes(),
- quality: 1,
- });
- if (result.canceled) return;
- const asset = result.assets[0];
- onAdd(asset.uri);
- }
- function getMediaTypes() {
- const mediaType = (ImagePicker as {
- MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
- }).MediaType;
- const imageType = mediaType?.Image ?? mediaType?.Images;
- const videoType = mediaType?.Video ?? mediaType?.Videos;
- if (imageType && videoType) {
- return [imageType, videoType];
- }
- return imageType ?? videoType ?? ['images', 'videos'];
- }
- function isVideoUri(uri: string) {
- return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
- }
- function normalizeMediaUri(uri?: string | null) {
- if (typeof uri !== 'string') return null;
- const trimmed = uri.trim();
- return trimmed ? trimmed : null;
- }
- function uniqueMediaUris(uris: string[]) {
- const seen = new Set<string>();
- const result: string[] = [];
- for (const uri of uris) {
- if (!uri || seen.has(uri)) continue;
- seen.add(uri);
- result.push(uri);
- }
- return result;
- }
- function parseTaskMeta(raw: string | null) {
- if (!raw) return {} as { photoUri?: string };
- try {
- return JSON.parse(raw) as { photoUri?: string };
- } catch {
- return {} as { photoUri?: string };
- }
- }
- function serializeTaskMeta(meta: { photoUri?: string }) {
- if (!meta.photoUri) return null;
- return JSON.stringify(meta);
- }
- const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- keyboardAvoid: {
- flex: 1,
- },
- content: {
- padding: 16,
- gap: 10,
- paddingBottom: 40,
- },
- input: {
- borderRadius: 10,
- borderWidth: 1,
- paddingHorizontal: 12,
- paddingVertical: 10,
- fontSize: 15,
- },
- mediaPreview: {
- width: '100%',
- height: 220,
- borderRadius: 12,
- backgroundColor: '#1C1C1C',
- },
- photoRow: {
- flexDirection: 'row',
- gap: 8,
- },
- actions: {
- marginTop: 12,
- flexDirection: 'row',
- justifyContent: 'flex-end',
- alignItems: 'center',
- gap: 10,
- },
- photoPlaceholder: {
- opacity: 0.6,
- },
- mediaStrip: {
- marginTop: 6,
- },
- mediaChip: {
- width: 72,
- height: 72,
- borderRadius: 10,
- marginRight: 8,
- overflow: 'hidden',
- backgroundColor: '#E6E1D4',
- alignItems: 'center',
- justifyContent: 'center',
- },
- mediaThumb: {
- width: '100%',
- height: '100%',
- },
- videoThumb: {
- width: '100%',
- height: '100%',
- backgroundColor: '#1C1C1C',
- alignItems: 'center',
- justifyContent: 'center',
- },
- videoThumbText: {
- color: '#FFFFFF',
- fontSize: 18,
- fontWeight: '700',
- },
- mediaRemove: {
- position: 'absolute',
- top: 4,
- right: 4,
- width: 18,
- height: 18,
- borderRadius: 9,
- backgroundColor: 'rgba(0,0,0,0.6)',
- alignItems: 'center',
- justifyContent: 'center',
- },
- mediaRemoveText: {
- color: '#FFFFFF',
- fontSize: 12,
- lineHeight: 14,
- fontWeight: '700',
- },
- updateGroup: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- },
- inlineToastText: {
- fontWeight: '700',
- fontSize: 12,
- },
- statusRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- marginBottom: 12,
- },
- });
|